查看原文
其他

开源教程 | 用 Mapbox 进行高级地图着色(下)柔和阴影与环境光 - Mapbox 一分钟

Mapbox 2019-06-01


我们上个礼拜为大家带来的地图着色教程,已经帮助大家实现了这样的效果。



本期我们将会一起用 柔和阴影(soft shadows)和环境照明(ambient lighting)完善自己的作品。为了提高速度,我们将会使用稍微有点难理解的 regl 库,并在 WebGL 的 GPU 上运行。


预期效果如下👇


添加柔和阴影


添加环境光


添加柔和阴影和环境光


添加卫星图

🌈柔和阴影(Soft Shadows)

源码

注意:对于本节和以下部分,我们将地形缩放四倍以增加着色效果的对比度。当然,你也可以换一个值,这是艺术选择。


真正的光,如太阳,有一定的尺寸,它们不是无限小的点光源。这就是创造柔和阴影的原因,如下图:



从 A 点的角度来看,可以看到整个光。从 B 点只能看到一部分光,而 C 点看不到任何光,因为它被完全遮挡。


想象一下,从 A 点移动到 C 点。最初,你看到整个光线,然后阻挡了一小部分光线,然后越来越多的光被阻挡,直到你看不到任何光线并完全处于阴影中。正是这种转变产生了柔和的阴影。


另外,光的大小影响柔和阴影的大小。光源越大,阴影越柔和。如果你从我们刚刚描述的从 A 到 C 的转换来考虑,是有道理的 - 从完全可见光转换到完全遮挡的光需要更长的时间。


为了计算柔和阴影,我们将把每个像素的光线投射到太阳上的一个随机点,多次,并在每个光线的强度上平均。当我们位于 A 点时,我们所有的光线都会照射到太阳上。在 B 点,有些光会照到太阳,有些会被挡住。在 C 点,所有都将被挡住。所有这些命中和未命中的平均值都会产生我们柔和阴影的值。


下面,我们来谈谈如何投射光线


👀快速像素遍历(Fast Pixel Traversal)

John Amanatides 和 Andrew Woo 在 1987 年发表了 《A Fast Voxel Traversal Algorithm for Ray Tracing》这篇论文。下面对论文中提到的一些重要概念,做个简单的概括。


这个算法由两个阶段构成:初始化和遍历


首先,来看下初始化。如下代码,我们从像素 p 开始,然后我们找到沿每个轴(stp)的光线方向的标志。接下来,看看需要在光线方向上行进多远。,才能与 x 和 y 方向上的下一个像素(tMax)相交。最后,确定必须沿着光线行进多远,才能覆盖像素的宽度和高度(tDelta)。


此时,我们可以开始算法的遍历阶段。这是伪代码:


while (true) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x
p.x += stp.x
} else {
tMax.y += tDelta.y
p.y += stp.y
}
if (exitedTile() or hitTerrain()) {
break
}
}


是不是特别简单?我们可以遍历整个纹理,不会遗漏任何像素。


👀Ping-pong 技术

我们平均所有阴影射线的方式是使用 Ping-pong 技术。我们将渲染到目标帧缓冲区,并在下一次迭代中将其用作源纹理。然后我们将再次交换它们并重复这个过程。


我们将渲染固定次数(N),因此每次迭代我们都会将 1 / N 加到我们累积的照明上。当完成时,我们渲染的最后一个帧缓冲区包含最终平均结果。可以看下下面的 Demo。


function PingPong(regl, opts) {
const fbos = [regl.framebuffer(opts), regl.framebuffer(opts)];

let index = 0;

function ping() {
return fbos[index];
}

function pong() {
return fbos[1 - index];
}

function swap() {
index = 1 - index;
}

return {
ping,
pong,
swap
};
}

👀执行过程

下面我们一起来执行一遍。


首先,建立 ping-pong 帧缓冲区。


const shadowPP = demo.PingPong(regl, {
width: image.width,
height: image.height,
colorType: "float"
});


下面,写一个 regl 命令,用来计算单个光线的光照,并将其添加到最后一次迭代的结果中。顶点着色器保持不变。


const cmdSoftShadows = regl({
vert: `
precision highp float;
attribute vec2 position;

void main() {
gl_Position = vec4(position, 0, 1);
}
`,


下面我们启动片段着色器(fragment shader)。


frag: `
precision highp float;

uniform sampler2D tElevation;
uniform sampler2D tNormal;
uniform sampler2D tSrc;
uniform vec3 sunDirection;
uniform vec2 resolution;
uniform float pixelScale;

void main() {
vec2 ires = 1.0 / resolution;


我们将从它们各自的纹理和前一次迭代(src)的照明中获取法线和高程。


vec3 src = texture2D(tSrc, gl_FragCoord.xy * ires).rgb;
vec4 e0 = texture2D(tElevation, gl_FragCoord.xy * ires);
vec3 n0 = texture2D(tNormal, gl_FragCoord.xy * ires).rgb;


然后,我们将对太阳方向的二维分量进行归一化以得到我们的 2D 射线方向 sr。


vec2 sr = normalize(sunDirection.xy);


下面,我们将如上所述初始化像素遍历算法。


vec2 p0 = gl_FragCoord.xy;
vec2 p = floor(p0);
vec2 stp = sign(sr);
vec2 tMax = step(0.0, sr) * (1.0 - fract(p0)) + (1.0 - step(0.0, sr)) * fract(p0);
tMax /= abs(sr);
vec2 tDelta = 1.0 / abs(sr);


开始遍历。


for (int i = 0; i < 65536; i++) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x;
p.x += stp.x;
} else {
tMax.y += tDelta.y;
p.y += stp.y;
}


在每一步,我们将获取当前像素中心的标准化纹理坐标,并检查我们是否已离开瓦片的边界。如果离开了,我们将为此迭代添加一些照明,并停止遍历。

如下代码,我们正在执行 128 次迭代,这些迭代导致了数学误差,这里的点积考虑了照明角度。


vec2 ptex = ires * (p + 0.5);
if (ptex.x < 0.0 || ptex.x > 1.0 || ptex.y < 0.0 || ptex.y > 1.0) {
gl_FragColor = vec4(src + vec3(1.0/128.0) * clamp(dot(n0, sunDirection), 0.0, 1.0), 1.0);
return;
}


如果我们没有离开瓦片,需要看是否碰撞到任何地形。


让我们先得到当前像素的高程。


vec4 e = texture2D(tElevation, ptex);


我们将计算我们沿着 2D 光线行进的时间(t),并使用它来确定我们从起点沿着 3D 光线

到当前点的高程。


float t = distance(p + 0.5, p0);
float z = e0.r + t * pixelScale * sunDirection.z;

如果我们所在的像素的高程大于我们沿着 3D 光线的高程,就会撞到地形。我们将存储上一次迭代的照明,并为此迭代添加零。


if (e.r > z) {
gl_FragColor = vec4(src, 1.0);
return;
}

我们设置循环迭代次数比遍历瓦片所需的次数多,所以我们一般不会遇到这种情况。但如果完成循环,让我们假装它已被照亮。你也可以在这里渲染一个独特的颜色来尝试调试。

}
gl_FragColor = vec4(src + vec3(1.0/128.0) * clamp(dot(n0, sunDirection), 0.0, 1.0), 1.0);
}
`,


regl 命令有一些值得注意的地方

  • 禁用深度缓冲区以确保每次都可以写入

  • 为 sunDirection 以及源和目标帧缓冲区(分别为 src 和 dest)添加 regl props(本质上是参数)。


depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: fboElevation,
tNormal: fboNormal,
tSrc: regl.prop("src"),
sunDirection: regl.prop("sunDirection"),
pixelScale: pixelScale,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
framebuffer: regl.prop("dest"),
count: 6
});

现在命令已经完成,我们将调用它 128 次(更早回调标准化常量)。每次调用它时,我们都会交换 ping-pong 帧缓冲区并计算新的太阳方向。下面的 100 表示太阳半径的倍数,因此我们使用的假太阳比真实太阳大 100 倍(就其半径而言)。

在最后一帧(i === 127),我们将渲染到屏幕上,而不是帧缓冲。


for (let i = 0; i < 128; i++) {
cmdSoftShadows({
sunDirection: vec3.normalize(
[],
vec3.add(
[],
vec3.scale([], vec3.normalize([], [1, 1, 1]), 149600000000),
vec3.random([], 695508000 * 100)
)
),
src: shadowPP.ping(),
dest: i === 127 ? undefined : shadowPP.pong()
});
shadowPP.swap();
}

最终结果如下。


软阴影


这里我们用硬阴影来做个对比(渲染的时候,上面提到的 100 被设置为 0)。


硬阴影


🌈环境光(Ambient Lighting)

源码


环境光是到达表面的所有光的总和。在地图应用程序的上下文中,我们认为这是从天空照射地图的光,而不是太阳。重要的是,这种光被附近的地形遮挡,所以高原的环境光量相对高,而深入缝隙的环境光量应该很低。


计算环境光量与为软阴影执行的光照计算非常相似。主要的不同是,我们不是将射线射向太阳,而是从地面上随机发射,看看它们是否撞击地形。如果不撞击,就增加照明;如果撞击,就不增加照明。


由于我们再次取平均值,还需要在此处创建 ping-pong 帧缓冲区。


const ambientPP = demo.PingPong(regl, {
width: image.width,
height: image.height,
colorType: "float"
});


regl 命令几乎相同,下面是不同的地方。

const cmdAmbient = regl({
vert: `
precision highp float;
attribute vec2 position;

void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;

uniform sampler2D tElevation;
uniform sampler2D tNormal;
uniform sampler2D tSrc;
uniform vec3 direction;
uniform vec2 resolution;
uniform float pixelScale;

void main() {
vec2 ires = 1.0 / resolution;
vec3 src = texture2D(tSrc, gl_FragCoord.xy * ires).rgb;
vec4 e0 = texture2D(tElevation, gl_FragCoord.xy * ires);
vec3 n0 = texture2D(tNormal, gl_FragCoord.xy * ires).rgb;

以下是我们计算光线方向的方法👇


想象一个半径为 1 米的球体在当前像素的表面上。从球体的体积中随机选择一个点。创建从曲面点到随机点的矢量,并对其进行标准化。这就是是光线方向。这样可以产生一个光线分布,类似于光线从完全漫反射的表面反射。


在代码中实现起来非常简单。

vec3 sr3d = normalize(n0 + direction);
The rest of the regl command is pretty much identical, except that we don’t scale the illumination by the dot product of the surface normal with the ray direction - that’s already taken care of by the distribution of rays we’re generating.

vec2 sr = normalize(sr3d.xy);
vec2 p0 = gl_FragCoord.xy;
vec2 p = floor(p0);
vec2 stp = sign(sr);
vec2 tMax = step(0.0, sr) * (1.0 - fract(p0)) + (1.0 - step(0.0, sr)) * fract(p0);
tMax /= abs(sr);
vec2 tDelta = 1.0 / abs(sr);
for (int i = 0; i < 65536; i++) {
if (tMax.x < tMax.y) {
tMax.x += tDelta.x;
p.x += stp.x;
} else {
tMax.y += tDelta.y;
p.y += stp.y;
}
vec2 ptex = ires * (p + 0.5);
if (ptex.x < 0.0 || ptex.x > 1.0 || ptex.y < 0.0 || ptex.y > 1.0) {
gl_FragColor = vec4(src + vec3(1.0/128.0), 1.0);
return;
}
vec4 e = texture2D(tElevation, ptex);
float t = distance(p + 0.5, p0);
float z = e0.r + t * pixelScale * sr3d.z;
if (e.r > z) {
gl_FragColor = vec4(src, 1.0);
return;
}
}
gl_FragColor = vec4(src + vec3(1.0/128.0), 1.0);
}
`,
depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tElevation: fboElevation,
tNormal: fboNormal,
tSrc: regl.prop("src"),
direction: regl.prop("direction"),
pixelScale: pixelScale,
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
framebuffer: regl.prop("dest"),
count: 6
});


最后,我们提供的随机矢量,在一个单位球中具有随机长度。


forlet i = 0; i <128; i ++){
  cmdAmbient({
direction:vec3.random([],Math.random()),
src:ambientPP.ping(),
dest:i === 127undefined:ambientPP.pong()
  });
  ambientPP.swap();
}


结果显示如下。


环境光照效果


🌈组合光

源码


现在我们已经拥有了软阴影和环境光两个照明组件,我们可以简单地将它们组合在一起以创建最终的地图效果。


在组合之前,我们需要更改 cmdSoftShadow 和 cmdAmbient 中的下面几行。


dest: i === 127 ? undefined : shadowPP.pong()
dest: i === 127 ? undefined : ambientPP.pong()

我们需要将上面两行修改为下面两行。


dest: shadowPP.pong()
dest: ambientPP.pong()

这样,我们将每个组件存储在 ping-pong 帧缓冲区的 ping() 中。请注意,虽然最终目标是 pong(),但是在循环结束时会发生最终交换。


添加照明有一种艺术化的方式,我们将每个光照分量乘以个因子。比如下面代码中,软阴影照明的因数为 1.0,环境光的因数为 0.25。您可以根据自己的喜好去自由调整。


const cmdFinal = regl({
vert: `
precision highp float;
attribute vec2 position;

void main() {
gl_Position = vec4(position, 0, 1);
}
`,
frag: `
precision highp float;

uniform sampler2D tSoftShadow;
uniform sampler2D tAmbient;
uniform vec2 resolution;

void main() {
vec2 ires = 1.0 / resolution;
float softShadow = texture2D(tSoftShadow, ires * gl_FragCoord.xy).r;
float ambient = texture2D(tAmbient, ires * gl_FragCoord.xy).r;
float l = 1.0 * softShadow + 0.25 * ambient;
gl_FragColor = vec4(l,l,l, 1.0);
}
`,
depth: {
enable: false
},
attributes: {
position: [-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]
},
uniforms: {
tSoftShadow: shadowPP.ping(),
tAmbient: ambientPP.ping(),
resolution: [image.width, image.height]
},
viewport: { x: 0, y: 0, width: image.width, height: image.height },
count: 6
});

cmdFinal();

最终结果如下所示。


软阴影与环境光结合的效果


如果调整太阳半径的倍数,比如设置为 1,是这样的效果。


软阴影和环境光结合,太阳半径设置为 1 倍


🌈添加颜色

源码


我们可以添加任意基色来应用我们的照明。比如我们来试试添加卫星图像。


注意:卫星图像已经带有来自太阳的烘烤照明。如果放大得足够近,通常可以看到阴影。目前可能还没有一种很好的方法可以直接解决这个问题(虽然有论文在研究卫星阴影去除)。最简单的办法可能是缩小或使用不同的图像集。


让我们从 Mapbox 下载卫星瓦片并用它创建一个纹理。


const satelliteImage = await demo.loadImage(
`https://api.mapbox.com/v4/mapbox.satellite/${zoom}/${tLong}/${tLat}.pngraw?access_token=${MAPBOX_KEY}`
);

const tSatellite = regl.texture({
data: satelliteImage,
flipY: true
});
In our fragment shader, we’ll pull in the satellite texture:

uniform sampler2D tSatellite;
We’ll grab the texture color:

vec3 satellite = texture2D(tSatellite, ires * gl_FragCoord.xy).rgb;
Tweak our lighting a bit:

float l = 4.0 * softShadow + 0.25 * ambient;

通过应用曲线稍微加深卫星纹理的颜色。


vec3 color = l * pow(satellite, vec3(2.0));

最后用伽马矫正,展示结果。


color = pow(color, vec3(1.0/2.2));
gl_FragColor = vec4(color, 1.0);

当太阳半径倍数是 100 的时候,结果如下。


结合软阴影和环境光照,叠加卫星图像,太阳半径倍数是 100 的效果


当太阳半径倍数是 1 的时候的效果如下。


结合软阴影和环境光照,叠加卫星图像,太阳半径倍数是 1 的效果


当然,您也可以尝试使用任何一种地图样式,比如下面这样。


结合软阴影和环境光照,叠加 Mapbox Street Tiles,太阳半径倍数是 100 的效果


🌈Tiling

源码


下面是两个相邻的瓦片。



两个独立渲染的瓦片


单独来看,它们看起来是很不错的。但是,如果并排显示其中两个瓦片,则会有点不太契合,如下图所示。



哎,怎么会这样呢?


有这么几种可能的原因导致这样:

  • 首先,如前所述,每个瓦片的正边缘处的法线是不明确的。

  • 其次,软阴影和环境光照明的问题也存在同样的问题,但会更糟糕一些,因为它们需要的信息更多 —— 它们需要能看到,来自太阳或天空的,任何可能遮挡它们的东西。


如何解决这个问题呢?有一种方法是渲染目标图块及其周围的八个图块,然后提取目标图块。一起来看一下怎么做吧。


首先,让我们编写一个返回 Canvas 对象的函数,该对象包含中心的目标图块和八个周围的图块。


async function getRegion(tLat, tLong, zoom, api) {
const canvas = document.createElement("canvas");
canvas.width = 3 * 256;
canvas.height = 3 * 256;
const ctx = canvas.getContext("2d");
for (let x = 0; x < 3; x++) {
const _tLong = tLong + (x - 1);
for (let y = 0; y < 3; y++) {
const _tLat = tLat + (y - 1);
const url = api
.replace("zoom", zoom)
.replace("tLat", _tLat)
.replace("tLong", _tLong);
const img = await loadImage(url);
ctx.drawImage(img, x * 256, y * 256);
}
}
return canvas;
}

下面,我们来提取它的高程。


const image = await demo.getRegion(
tLat,
tLong,
zoom,
`https://api.mapbox.com/v4/mapbox.terrain-rgb/zoom/tLong/tLat.pngraw?access_token=${MAPBOX_KEY}`
);

就像下面这样。


在单个图像中定位具有八个邻居的 terrain-rgb 瓦片


接下来,我们需要改变计算 pixelScale 的方式,以便考虑新的经度范围。


long0 = demo.tile2long(tLong - 1, zoom);
long1 = demo.tile2long(tLong + 2, zoom);
const pixelScale =
(6371000 * (long1 - long0) * 2 * Math.PI) / 360 / image.width;
And of course we need the large satellite image as well:

const satelliteImage = await demo.getRegion(
tLat,
tLong,
zoom,
`https://api.mapbox.com/v4/mapbox.satellite/zoom/tLong/tLat.pngraw?access_token=${MAPBOX_KEY}`
);

现在我们像以前一样渲染相同的两个目标瓦片,但是相邻的图块,然后从每个图块中提取中心。首先是左边。


提取左边的瓦片


然后提取右边的瓦片。


最终结果好了很多,没有明显的不连接感。


👀Tiling 陷阱


如果您的阴影或环境光照效果拉伸超过一块瓦片,它们可能会产生伪影。可能的解决办法如下:

  • 对于出现此问题的阴影,可以尝试抬高光源,使它们不会被拉伸到远处。

  • 可以增加渲染图块的半径,直到它们包含受阴影和环境光照影响的区域。

  • 可以在正在渲染的切片半径上淡化阴影和遮挡的强度,强制它们在到达切片边缘之前为零。这可能会导致“方形”阴影和遮挡,但也可能会导致强度突然下降。


如果在对软阴影和环境光照进行平均时使用的样本数较少,则可能会看到每个瓦片的不同样本分布会产生伪影。这很容易解决:创建一个随机向量列表,然后为每个瓦片重用它们,而不是为每个瓦片生成一个新集合


我还没有尝试过,但处理缩放功能可能会很棘手。我们将无法以高变焦计算光照,然后通过合并图像来构建较低缩放级别的图像。在几个缩放级别之后,阴影和环境光的效果将会消失,因为它们相对于地图的尺寸而言会很小。我们可能会尝试重新计算每个缩放级别的光照,适当增加或减少高程比例以使着色效果缩放。


👀Tiling 优化

  • 我们需要计算所有瓦片中所有像素的光照。其实没有必要这样做。只需计算中心区域的像素即可。

  • 缓存所有对缓存有意义的瓦片。没有必要为一个瓦片多次重新计算高程,也不需要多次下载它。


🌈距离最终作品就差一步了


整个项目的源码在这里👇

https://github.com/wwwtyro/map-tile-lighting-demo


想要运行这个 Demo,您需要提供自己的 Mapbox Key,创建帐户并获取密钥后,将其拖入 Demo Repo 根目录中名为 mapbox.key 的文件中。


当然,您也可以在 3D 网格上使用此技术。您只需要生成具有适当 UV 坐标的网格并应用您生成的纹理。


扫描文末二维码,在 Mapbox 微信公众号后台回复“渲染图”即可前下载根据本教程做出来的美国 50 个州的高清渲染图片,就像下面这样。



3D 的 Grand Canyon


2D 和 3D 的 San Francisco


作者的家乡 Westside El Paso


原文来源:

https://wwwtyro.net/2019/03/21/advanced-map-shading.html


👀相关阅读



关注公众号,回复“渲染图”提前下载根据本教程做出来的美国 50 个州的高清渲染图片哦。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存